/*
 * Variable.java
 *
 * Created on July 31, 2007, 8:19 PM
 *
 *  Copyright (C) 2010 Tal Eisenberg
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package tal.drivechain.variable;

import tal.drivechain.variable.types.DataType;
import java.io.Serializable;
import tal.drivechain.chain.ChainNode;
import tal.drivechain.chain.script.ScriptGenerator;
import tal.drivechain.parsing.ParsingAid;
import tal.drivechain.parsing.Tokenizer;

/**
 * The Variable class is responsible for creating and holding chain variables and
 * contains methods that help differen variable parsing tasks.
 * <p>
 * The Variable definition syntax is, <data name>:<data type>:<value> while each data type has
 * a different value syntax. 
 *
 * @author Tal Eisenberg
 */
public class Variable implements Serializable, ScriptGenerator {
    public enum SCRIPTType { full, typeValue, value };

    private String name;
    private Object value;
    private DataType type;
    private boolean evaluateAtHome = true;
    private SCRIPTType scriptType = SCRIPTType.full;

    /** Creates a new instance of Variable with type checking.
     *
     * @param name The name of the variable
     * @param type A DataType object representing the data type of the variable.
     * @param value The value of the variable.
     * @param globalVars A reference to the variables global var table. Used for data types that
     *        needs to be evaluated in getValue() such as expr and var.
     */
   public Variable(String name, DataType type, Object value) throws IllegalArgumentException {
        this(name, type, value, false);
   }
   
   /** Creates a new instance of Variable with the option to not check the variable value against the datatype in order to 
    * create the variable faster.
    */
   public Variable(String name, DataType type, Object value, boolean typechecked) throws IllegalArgumentException {
        if (!typechecked) {
            if (!type.checkType(value)) throw new IllegalArgumentException("Invalid " + type.getName() + " value: " + value);
        }
        if (name==null) throw new NullPointerException("variable name can't be null.");
        if (type==null) throw new NullPointerException("variable type can't be null.");
        this.name = name;
        this.value = value;
        this.type = type;

       //System.out.println("NEWVAR: " + name + "(" + type.getName() + ":" + value + ")");
   }

    public Variable evaluate(ChainNode context) {
        Variable v = type.evaluateVar(this, context);
        if (v!=this) v.setName(this.getName());
        return v;
    }
   
   /**
    * Determines if the variable's value will be evaluated into a fixed value before being sent
    * out from a module.
    */
   public void setEvaluateAtHome(boolean b) {
       evaluateAtHome = b;
   }

   /**
    * Creates a variable from a definition string possibly with a default variable name.
    * The definition format is name(type:value). To use the default name the definition
    * should be in the format of (type:value) including the parenthesis.
    *
    * @param defaultName The default name for the variable if def is in the format of (type:value).
    *                    If null def must have a name (format of: name(type:value)).
    * @param def         A string containing the variable definition.
    * @param globalVars  A reference to a VarTable that is used if a global variable is needed to
    *                    evaluate the variables value.
    * @param unnamed makes sure def doesn't use a variable name in the definition.
    * @return            a Variable object according to the def string.
    */
   public static Variable parseVariable(String defaultName, DataType defaultType, String def, boolean unnamedOnly) throws IllegalArgumentException {
       //name(vartype:varvalue)
       String name = null;
       DataType type = null;
       Object value = null;
       boolean dontEval = false;
       SCRIPTType vscriptType = SCRIPTType.full;

       String typevalue = null;

       String openBalance = "([{'", closeBalance = ")]}'";

       //find (type:value) by finding the first unnested opening (.
       String symbol = "()";
       int openIdx = ParsingAid.indexOfUnnested(def, symbol.charAt(0), openBalance, closeBalance);

       if (openIdx!=-1) { // probably has type:value of some sort:
           int closeIdx = Tokenizer.balanced(def, openIdx, symbol.charAt(0), symbol.charAt(1));
           if (closeIdx==-1)
               throw new IllegalArgumentException("Missing closing parenthesis '" + symbol.charAt(1) + "' in variable defintion: " + def);
           //type value = the stuff between openIdx and closeIdx

           typevalue = def.substring(openIdx+1, closeIdx).trim();


           // if unnamedOnly=true and no variable name use defaultName or freename if null
           // else use default name or throw exception if null.
           if (openIdx==0) { //no variable name in definition
               if (defaultName==null) {
                   if (unnamedOnly) //get freename
                       name = getFreeVarName();
                   else throw new IllegalArgumentException("Missing variable name in definition: " + def);
               } else name = defaultName;
               vscriptType = SCRIPTType.typeValue;
           } else {
               if (unnamedOnly)
                   throw new IllegalArgumentException("Explicit variable name is not allowed in this definition: " + def);
               else name = def.substring(0, openIdx).trim();
               
               if (!VarTable.isValidVarName(name)) throw new IllegalArgumentException("Illegal variable name: '" + name + "'");
               vscriptType = SCRIPTType.full;
           }
           if (typevalue.length()==0) {
               throw new IllegalArgumentException("A datatype is required: " + def);
           } else {
               //type = before :, value = after :
               dontEval = (typevalue.charAt(0)=='~');
               if (dontEval) typevalue = typevalue.substring(1);
               int colonIdx = ParsingAid.indexOfUnnested(typevalue, ':', openBalance, closeBalance);
               if (colonIdx==-1) { // can either be name(value) or name(~value)
                    type = DataType.findTypeByDef(typevalue);
               } else {
                    String sType = typevalue.substring(0, colonIdx);
                    type = DataType.parseType(sType);
               }
               
               value = type.parseValue(typevalue.substring(colonIdx+1).trim());
           }
       } else {
           // try value only:
           dontEval = (def.charAt(0)=='~');
           if (dontEval) def = def.substring(1);

           if (def.startsWith("@")) {
               value = null;
               type = DataType.EVENT;
               name = def.substring(1);
           } else {
               if (defaultName==null) {
                   if (unnamedOnly) //get freename
                       name = getFreeVarName();
                   else throw new IllegalArgumentException("A variable name is required: " + def);
               } else name = defaultName;
               
               if (defaultType==null) {
                   type = DataType.findTypeByDef(def);
               } else type = defaultType;
               value = type.parseValue(def);
               vscriptType = SCRIPTType.value;
           }
       }

       Variable ret = new Variable(name, type, value, true);
       ret.setScriptType(vscriptType);
       ret.setEvaluateAtHome(!dontEval);
       return ret;
   }
   
   /**
    * Creates a Variable with the given name and data type. The definition contains only the value of the
    * variable.
    */
    public static Variable parseVarValue(String name, DataType type, String value) throws IllegalArgumentException {
        Variable v = new Variable(name, type, type.parseValue(value), true);
        v.setScriptType(SCRIPTType.typeValue);
        return v;
    }

    /**
     * Returns the data type of the variable.
     *
     * @return a DataType object that used by this variable.
     */
    public DataType getDataType() {
        return this.type;
    }
    
    /**
     * Returns the value of the variable.
     *
     * @param localVars Used for evaluating the value if necessary.
     */
    public Object getValue(ChainNode context) {
        return type.extractValue(value, context);
    }
    
    /**
     * Returns the internal Object that the variable uses to store the value. 
     * In case of expr the returned Object will be the expression before evaluation and in the
     * case of var the returned Object will be only a reference object to the variable that's pointed too.
     */
    public Object getInternalValue() { return value; }
    
    /**
     * Copies the value of the given Variable object into this this Variable.
     *
     * @param var   The variable that will be copied.
     * @throws IllegalArgumentException if the data type of the Variables doesn't match.
     */
    public void setValue(Variable var) throws IllegalArgumentException {
        // this is probably much worse than setting the value to an Object
        // consider changing... (even though type check is not necessary here)
        if (var.getDataType().matches(type)) {
            this.value = var.getInternalValue();
        } else {
            this.value = getDataType().cast(var.getInternalValue());
        }
    }
    
    /**
     * Sets the given value Object as this Variables value. Will check the type match if needed.
     *
     * @param value The new value of the Variable.
     * @param typechecked   True if the new value was already checked to be a valid value for this
     *                      Variable's data type
     * @throws IllegalArgumentException If the value is not a valid value for this Variable's type.
     */
    public void setValue(Object value, boolean typechecked) throws IllegalArgumentException {
        if (!typechecked && !type.checkType(value)) this.value = type.cast(value);
        else this.value = value;
    }    

    /**
     * Changes the data type of this variable object. If newType is different than the current type 
     * The variable value is set to null.
     * 
     * @param newType The new data type of the variable.
     */
    public void setDataType(DataType newType) {
        if (!newType.matches(type)) {
            this.type = newType;
            this.value = null;
        }
    }

    /**
     * Returns the name of the variable
     */
    public String getName() {
        return this.name;
    }
    
    /**
     * Tries to return a String representation of the Variable without evaluating it's value.
     */
    @Override
    public String toString() { 
        return toString(null);
    }
    
    /**
     * Returns a String representation of the variable.
     * The format of the returned string is: <varname>:<value>
     * @param localVars Used for evaluating the variable value before sending the String.
     */
    public String toString(ChainNode context) {
        if (type.matches(DataType.EVENT))
            return "@" + this.getName();
        else {
            switch (scriptType) {
                case full:
                    return this.getName() + "(" + (isEvaluateAtHome()?"":"~") + this.getValueDefinition(context) + ")";
                case typeValue:
                    return "(" + this.getValueDefinition(context) + ")";
                case value:
                    return (isEvaluateAtHome()?"":"~") + type.toString(getInternalValue(), null);
                default:
                    throw new IllegalArgumentException("Unknown SCRIPTType: " + scriptType.name());
            }
        }
    }
    
    /**
     * Returns a shallow copy of this Variable. The new variable shares the value object of this
     * variable.
     */
    public Variable copyVar() {
        Variable v = new Variable(this.name, this.type, this.value, true);
        v.setEvaluateAtHome(evaluateAtHome);

        return v;
    }    

    /**
     * Sets whether this variable should be evaluated before being sent of the chain.
     */
    public boolean isEvaluateAtHome() {
        return this.evaluateAtHome;
    }
    
    public void setName(String name) { this.name = name; }

    public String generateScript() {
        return toString(null);
    }

    /**
     * Returns a String that contains a variable name that wasn't used before.
     */
    private static int unknownId = 0;

    public static String getFreeVarName() {
        unknownId++;
        return "freename$" + (unknownId-1);
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Variable) {
            if (super.equals(obj)) return true;
            else {
                Variable that = (Variable)obj;
                if (this.name==null && that.name!=null) return false;
                if (this.name!=null && that.name==null) return false;
                if (name!=null && !this.name.equals(that.name)) return false;

                if (this.value==null && that.value!=null) return false;
                if (this.value!=null && that.value==null) return false;
                if (value!=null && !this.value.equals(that.value)) return false;

                if (this.type==null && that.type!=null) return false;
                if (this.type!=null && that.type==null) return false;
                if (type!=null && !this.type.matches(that.type)) return false;

                if (this.evaluateAtHome!=that.evaluateAtHome) return false;
                return true;
            }
        } else return false;
    }

    @Override
    public int hashCode() {
        int hash = 7;
        hash = 97 * hash + (this.name != null ? this.name.hashCode() : 0);
        hash = 97 * hash + (this.value != null ? this.value.hashCode() : 0);
        hash = 97 * hash + (this.type != null ? this.type.hashCode() : 0);
        hash = 97 * hash + (this.evaluateAtHome ? 1 : 0);
        return hash;
    }

    public String getValueDefinition(ChainNode context) {
        return (evaluateAtHome?"":"~") + this.getDataType().getName() + ":" + this.getDataType().toString(this.getInternalValue(), context);
    }

    public void setScriptType(SCRIPTType type) {
        this.scriptType = type;
    }
}
